forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { H3Event } from 'h3'
2import * as v from 'valibot'
3import { PackageRouteParamsSchema } from '#shared/schemas/package'
4import { SkillNameSchema } from '#shared/schemas/skills'
5import type { SkillsListResponse, SkillContentResponse } from '#shared/types'
6import {
7 CACHE_MAX_AGE_ONE_HOUR,
8 CACHE_MAX_AGE_ONE_YEAR,
9 ERROR_SKILLS_FETCH_FAILED,
10 ERROR_SKILL_NOT_FOUND,
11 ERROR_SKILL_FILE_NOT_FOUND,
12} from '#shared/utils/constants'
13import { parsePackageParam } from '#shared/utils/parse-package-param'
14
15const CACHE_VERSION = 1
16
17/**
18 * Skills discovery and content endpoint.
19 *
20 * URL patterns:
21 * - /skills/vue/v/3.4.0 → discovery (list skills)
22 * - /skills/vue/v/3.4.0/my-skill → skill content (SKILL.md parsed)
23 * - /skills/vue/v/3.4.0/my-skill/refs/guide.md → supporting file (raw)
24 * - /skills/@scope/pkg/v/1.0.0 → scoped package
25 */
26export default defineCachedEventHandler(
27 async event => {
28 const pkgParam = getRouterParam(event, 'pkg')
29 if (!pkgParam) {
30 throw createError({ statusCode: 404, message: 'Package name is required' })
31 }
32
33 const { packageName, version: rawVersion, rest } = parsePackageParam(pkgParam)
34
35 try {
36 const validated = v.parse(PackageRouteParamsSchema, { packageName, version: rawVersion })
37
38 let version = validated.version
39 let isVersioned = !!version
40 if (!version) {
41 const packument = await fetchNpmPackage(validated.packageName)
42 version = packument['dist-tags']?.latest
43 if (!version) {
44 throw createError({ statusCode: 404, message: 'No latest version found' })
45 }
46 }
47
48 // Set cache headers: 1 year for versioned, 1 hour for latest
49 if (isVersioned) {
50 setHeader(event, 'Cache-Control', `public, max-age=${CACHE_MAX_AGE_ONE_YEAR}, immutable`)
51 }
52
53 if (rest.length === 0) {
54 return await handleDiscovery(validated.packageName, version)
55 }
56
57 const skillName = v.parse(SkillNameSchema, rest[0])
58
59 if (rest.length === 1) {
60 return await handleSkillContent(validated.packageName, version, skillName)
61 }
62
63 const filePath = rest.slice(1).join('/')
64 return await handleSkillFile(event, validated.packageName, version, skillName, filePath)
65 } catch (error) {
66 handleApiError(error, { statusCode: 502, message: ERROR_SKILLS_FETCH_FAILED })
67 }
68 },
69 {
70 maxAge: CACHE_MAX_AGE_ONE_HOUR,
71 swr: true,
72 getKey: event => {
73 const pkg = getRouterParam(event, 'pkg') ?? ''
74 return `skills:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}`
75 },
76 },
77)
78
79async function handleDiscovery(packageName: string, version: string): Promise<SkillsListResponse> {
80 const fileTree = await getPackageFileTree(packageName, version)
81 const skillDirs = findSkillDirs(fileTree.tree)
82
83 if (skillDirs.length === 0) {
84 return { package: packageName, version, skills: [] }
85 }
86
87 const skills = await fetchSkillsList(packageName, version, skillDirs)
88 return { package: packageName, version, skills }
89}
90
91async function handleSkillContent(
92 packageName: string,
93 version: string,
94 skillName: string,
95): Promise<SkillContentResponse> {
96 try {
97 const { frontmatter, content } = await fetchSkillContent(packageName, version, skillName)
98 return { package: packageName, version, skill: skillName, frontmatter, content }
99 } catch (error) {
100 if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
101 throw createError({ statusCode: 404, message: ERROR_SKILL_NOT_FOUND })
102 }
103 throw error
104 }
105}
106
107async function handleSkillFile(
108 event: H3Event,
109 packageName: string,
110 version: string,
111 skillName: string,
112 filePath: string,
113): Promise<string> {
114 // Validate file path to prevent directory traversal
115 if (filePath.includes('..') || filePath.startsWith('/')) {
116 throw createError({ statusCode: 400, message: 'Invalid file path' })
117 }
118
119 // Only allow files within skill subdirectories (scripts/, references/, assets/)
120 const allowedPrefixes = ['scripts/', 'references/', 'assets/', 'refs/']
121 if (!allowedPrefixes.some(p => filePath.startsWith(p))) {
122 throw createError({
123 statusCode: 400,
124 message: 'File must be in scripts/, references/, or assets/ subdirectory',
125 })
126 }
127
128 try {
129 const content = await fetchSkillFile(packageName, version, `skills/${skillName}/${filePath}`)
130
131 const ext = filePath.split('.').pop()?.toLowerCase() || ''
132 const contentTypes: Record<string, string> = {
133 md: 'text/markdown',
134 txt: 'text/plain',
135 json: 'application/json',
136 js: 'text/javascript',
137 ts: 'text/typescript',
138 sh: 'text/x-shellscript',
139 py: 'text/x-python',
140 }
141 setHeader(event, 'Content-Type', contentTypes[ext] || 'text/plain')
142
143 return content
144 } catch (error) {
145 if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
146 throw createError({ statusCode: 404, message: ERROR_SKILL_FILE_NOT_FOUND })
147 }
148 throw error
149 }
150}